﻿using System;
using System.Collections.Generic;
using System.Net;

namespace Safebrowsing
{
    public class SafebrowsingApi : IDisposable
    {
        private readonly BackoffPolicy m_BackoffPolicy;
        private readonly SafebrowsingApiServicePoint m_DefaultApiServicePoint;
        private readonly ErrorState m_ErrorState;
        private readonly MaintenanceWorker m_MaintenanceWorker;
        private readonly SafebrowsingDao m_SafebrowsingDao;
        private readonly object m_SyncRoot = new object();

        #region Public properties

        private bool m_IsBackoffMode;

        /// <summary>
        /// If "true" -- plugin is in backoff mode.
        /// Read more about backoff mode: http://code.google.com/apis/safebrowsing/developers_guide_v2.html#RequestFrequencyHashes
        /// </summary>
        public bool BackoffMode
        {
            get
            {
                lock (m_SyncRoot)
                {
                    return m_IsBackoffMode;
                }
            }
            private set
            {
                lock (m_SyncRoot)
                {
                    m_IsBackoffMode = value;
                }
            }
        }

        /// <summary>
        /// Applications retrieving data using the API must be certain never to use data older than 45 minutes. 
        /// What this means specifically is that a warning can be shown only in the following 3 scenarios:<br/>
        /// <ul>
        /// <li>
        /// A URL matches a full-length hash obtained in an add chunk returned as a response to an HTTP Request for Data, 
        /// provided that such hash has not been removed from the list (e.g. via a sub chunk), and further provided that 
        /// the list has been successfully updated via an HTTP Request for Data (where the entire update was successfully 
        /// processed) within the past 45 minutes from the time a warning is to be provided, or
        /// </li>
        /// <li>
        /// A URL matches a full length hash obtained in a response to an HTTP Request for Full Length Hashes, provided 
        /// that the prefix of the matching full length hash has not been removed from the list (e.g. via a sub chunk), 
        /// and further provided that the list has been successfully updated via an HTTP Request for Data (where the entire 
        /// update was successfully processed) within the past 45 minutes from the time a warning is to be provided, or
        /// </li>
        /// <li>
        /// A URL matches a full length hash obtained in a response to an HTTP Request for Full Length Hashes made within 
        /// the past 45 minutes from the time a warning is to be provided, provided that such a hash has not been subsequently 
        /// removed from the list (e.g. via a sub chunk)
        /// </li>
        /// </ul>
        /// Default value is 45 minutes.
        /// </summary>
        public TimeSpan MaxAgeOfData { get; set; }

        /// <summary>
        /// Gets or sets proxy to use for downloading lists
        /// </summary>
        public IWebProxy Proxy
        {
            get { return m_MaintenanceWorker.Proxy; }
            set { m_MaintenanceWorker.Proxy = value; }
        }

        #endregion

        #region Start/stop/status methods

        /// <summary>
        /// Returns true if safebrowsing api maintenance worker is started
        /// </summary>
        public bool IsStarted
        {
            get { return m_MaintenanceWorker.IsStarted; }
        }

        /// <summary>
        /// Starts maintenance thread (thread that is updating lists)
        /// </summary>
        public void Start()
        {
            m_MaintenanceWorker.Start();
        }

        /// <summary>
        /// Stops maintenance thread (thread that is updating lists)
        /// </summary>
        public void Stop()
        {
            m_MaintenanceWorker.Stop();
        }

        #endregion

        #region Lookup methods

        /// <summary>
        /// Checks if specified uri is in the specified list.
        /// Returns "true" if uri is in blacklist. Otherwise returns false.
        /// </summary>
        /// <param name="uri"></param>
        /// <param name="list"></param>
        /// <returns></returns>
        public bool LookupUrl(Uri uri, SafebrowsingList list)
        {
            if (CheckBackoffMode()) return false;

            try
            {
                List<int> hostKeys = Utils.GetHostKeys(uri);

                foreach (int hostKey in hostKeys)
                {
                    List<UrlHashEntry> entries = m_SafebrowsingDao.FindUrlHashes(hostKey, list);

                    if (entries == null || entries.Count == 0) return false;

                    UrlHashEntry match = LookupFullHash(uri, entries);
                    if (match != null) return true;
                }
            }
            catch (Exception ex)
            {
                Logger.Warn("Error lookuping url: {0}", ex);
                OnExceptionOccured();
            }

            return false;
        }

        /// <summary>
        /// Lookups specified url against the specified lists.
        /// If match found - returns list that has matched.
        /// If nothing found - returns "null".
        /// </summary>
        /// <param name="uri"></param>
        /// <param name="lists"></param>
        /// <returns></returns>
        public SafebrowsingList LookupUrl(Uri uri, List<SafebrowsingList> lists)
        {
            if (CheckBackoffMode()) return null;

            // No error occurred -- cleaning error state
            if (m_ErrorState.ErrorsCount > 0) m_ErrorState.Clean();
            if (BackoffMode) BackoffMode = false;

            try
            {
                List<int> hostKeys = Utils.GetHostKeys(uri);

                foreach (int hostKey in hostKeys)
                {
                    List<UrlHashEntry> entries = m_SafebrowsingDao.FindUrlHashes(hostKey, lists);

                    if (entries != null && entries.Count > 0)
                    {
                        UrlHashEntry match = LookupFullHash(uri, entries);

                        if (match != null) return match.List;
                    }
                }

                return null;
            }
            catch (Exception ex)
            {
                Logger.Warn("Error lookuping url: {0}", ex);
                OnExceptionOccured();
            }

            return null;
        }

        /// <summary>
        /// Lookups full hash value. If matches - returns UrlHashEntry. Otherwise returns null.
        /// </summary>
        /// <param name="uri"></param>
        /// <param name="entries"></param>
        /// <returns></returns>
        private UrlHashEntry LookupFullHash(Uri uri, IEnumerable<UrlHashEntry> entries)
        {
            List<string> expressions = Utils.GetHashedSuffixPrefixExpressions(uri, 4, 4);

            foreach (UrlHashEntry entry in entries)
            {
                if (LookupFullHash(expressions, entry))
                {
                    CleanErrorState();
                    return entry;
                }
            }

            CleanErrorState();
            return null;
        }

        /// <summary>
        /// Lookups full hash value
        /// </summary>
        /// <param name="expressions">Suffix/prefix expressions to check</param>
        /// <param name="entry"></param>
        /// <returns></returns>
        private bool LookupFullHash(IEnumerable<string> expressions, UrlHashEntry entry)
        {
            string prefix = entry.Prefix ?? entry.HostKey.ToString("X").PadLeft(8, '0');

            foreach (string expression in expressions)
            {
                if (expression.StartsWith(prefix))
                {
                    IEnumerable<string> hashes = GetFullHashes(prefix, entry);

                    if (hashes != null)
                    {
                        foreach (string hash in hashes)
                        {
                            if (hash == expression) return true;
                        }
                    }
                }
            }

            return false;
        }

        private void CleanErrorState()
        {
            // No error occurred -- cleaning error state
            if (m_ErrorState.ErrorsCount > 0) m_ErrorState.Clean();
            if (BackoffMode) BackoffMode = false;
        }

        /// <summary>
        /// Returns full hash for the specified prefix. If entry contains full hash -- checks age of data.
        /// Otherwise downloads and saves full hash.
        /// </summary>
        /// <param name="prefix"></param>
        /// <param name="entry"></param>
        /// <returns></returns>
        private IEnumerable<string> GetFullHashes(string prefix, UrlHashEntry entry)
        {
            if (!string.IsNullOrEmpty(entry.FullHash))
            {
                DateTime timeUpdated = m_SafebrowsingDao.GetTimeUpdated(entry.List);
                if (DateTime.Now.Subtract(timeUpdated) < MaxAgeOfData)
                {
                    return entry.Hashes;
                }
            }

            SafebrowsingApiServicePoint apiServicePoint = entry.List.ApiServicePoint ?? m_DefaultApiServicePoint;
            var apiClient = new SafebrowsingApiClient(apiServicePoint);
            HashResponse hashResponse = apiClient.RequestHash(new HashRequest(prefix));

            // No such hash found
            if (hashResponse == null || hashResponse.HashResponseEntries == null ||
                hashResponse.HashResponseEntries.Count == 0)
                return null;

            // Searching for hash for this particular chunk and list
            foreach (HashResponseEntry hashEntry in hashResponse.HashResponseEntries)
            {
                if (hashEntry.AddChunkNumber == entry.ChunkNumber &&
                    hashEntry.List == entry.List &&
                    hashEntry.Hashes != null)
                {
                    string hash = string.Join(string.Empty, hashEntry.Hashes.ToArray());
                    if (hash != entry.FullHash)
                        m_SafebrowsingDao.SaveHash(entry.List, entry.ChunkNumber, entry.HostKey, entry.Prefix, hash);
                    return hashEntry.Hashes;
                }
            }

            return null;
        }

        #endregion

        #region Backoff mode

        /// <summary>
        /// Checks if client should stay in backoff mode or it should try and ping the server.
        /// Returns "true" if client should stay in backoff mode. Otherwise returns false.
        /// </summary>
        /// <returns></returns>
        private bool CheckBackoffMode()
        {
            if (!BackoffMode) return false;

            lock (m_SyncRoot)
            {
                if (DateTime.Now.Subtract(m_ErrorState.LastErrorTime) > m_BackoffPolicy.BackoffTimeout)
                {
                    m_ErrorState.Clean();
                }

                // Calculating seconds count we should not ping server from the last error
                TimeSpan delayToWait = m_ErrorState.ErrorsCount > m_BackoffPolicy.ErrorIntervals.Length
                                           ? m_BackoffPolicy.ErrorIntervals[m_BackoffPolicy.ErrorIntervals.Length - 1]
                                           : m_BackoffPolicy.ErrorIntervals[m_ErrorState.ErrorsCount - 1];

                // Backoff period is expired -- trying to ping one more time
                if (DateTime.Now.Subtract(m_ErrorState.LastErrorTime) > delayToWait) return true;

                return false;
            }
        }

        /// <summary>
        /// Called when exception occurred while getting full hash.
        /// Checking backoff policy (maybe we should enter backoff mode).
        /// </summary>
        private void OnExceptionOccured()
        {
            lock (m_SyncRoot)
            {
                if (BackoffMode)
                {
                    // Client is already in backoff mode. Doing nothing.
                    m_ErrorState.ErrorsCount += 1;
                    m_ErrorState.LastErrorTime = DateTime.Now;
                    return;
                }

                if (m_ErrorState.ErrorsCount == m_BackoffPolicy.ErrorsCountToEnterBackoffMode &&
                    DateTime.Now.Subtract(m_ErrorState.LastErrorTime) >
                    new TimeSpan(0, 0, m_BackoffPolicy.ErrorsTimeout))
                {
                    m_ErrorState.LastErrorTime = DateTime.Now;
                    m_ErrorState.ErrorsCount = 0;
                    BackoffMode = true;
                }
            }
        }

        #endregion

        #region Constuctors

        /// <summary>
        /// Creates an instance of the SafebrowsingApi.
        /// </summary>
        /// <param name="databaseLocation">Location of the SQLite database we use to store safebrowsing lists</param>
        /// <param name="apiKey">
        /// API key. From Google:<br/>
        /// First you need to request an API key, which will authenticate you as an API user. In order to obtain an API key, 
        /// you must have a Google account. You may create a Google account or log in with your existing Google account and 
        /// sign up for the API at http://www.google.com/safebrowsing/key_signup.html 
        /// </param>
        /// <param name="applicationVersion">Application version</param>
        /// <param name="lists">Collection of lists you are going to use</param>
        public SafebrowsingApi(string databaseLocation, string apiKey, string applicationVersion,
                               IEnumerable<SafebrowsingList> lists)
            : this(databaseLocation, apiKey, applicationVersion, lists, null, null, new DbMaintenancePolicy())
        {
        }

        /// <summary>
        /// Creates an instance of the SafebrowsingApi.
        /// </summary>
        /// <param name="databaseLocation">Location of the SQLite database we use to store safebrowsing lists</param>
        /// <param name="apiKey">
        /// API key. From Google:<br/>
        /// First you need to request an API key, which will authenticate you as an API user. In order to obtain an API key, 
        /// you must have a Google account. You may create a Google account or log in with your existing Google account and 
        /// sign up for the API at http://www.google.com/safebrowsing/key_signup.html 
        /// </param>
        /// <param name="applicationVersion">Application version</param>
        /// <param name="lists">Collection of lists you are going to use</param>
        /// <param name="maintenancePolicy">
        /// Requests for data policy. Look here for details: http://code.google.com/apis/safebrowsing/developers_guide_v2.html#RequestFrequencyData 
        /// <br/>If null -- using default policy.
        /// </param>
        /// <param name="backoffPolicy">
        /// Requests for full hash policy. Look here for details: http://code.google.com/apis/safebrowsing/developers_guide_v2.html#RequestFrequencyHashes 
        /// <br/>If null -- using default policy.
        /// </param>
        /// <param name="dbMaintenancePolicy">
        /// Policy for the database maintenance. If null -- there is no database maintenance thread.
        /// </param>
        public SafebrowsingApi(string databaseLocation, string apiKey, string applicationVersion,
                               IEnumerable<SafebrowsingList> lists,
                               MaintenancePolicy maintenancePolicy, BackoffPolicy backoffPolicy,
                               DbMaintenancePolicy dbMaintenancePolicy)
        {
            if (databaseLocation == null) throw new ArgumentNullException("databaseLocation");
            if (apiKey == null) throw new ArgumentNullException("apiKey");
            if (applicationVersion == null) throw new ArgumentNullException("applicationVersion");
            if (lists == null) throw new ArgumentNullException("lists");


            m_SafebrowsingDao = new SafebrowsingDao(databaseLocation, dbMaintenancePolicy);
            m_DefaultApiServicePoint = SafebrowsingApiServicePoint.CreateDefaultServicePoint(apiKey, applicationVersion);
            m_MaintenanceWorker = new MaintenanceWorker(maintenancePolicy ?? new MaintenancePolicy(), m_SafebrowsingDao,
                                                        m_DefaultApiServicePoint, lists);
            m_BackoffPolicy = backoffPolicy ?? new BackoffPolicy();
            m_ErrorState = new ErrorState();
            MaxAgeOfData = Constants.MAX_AGE_OF_DATA; // 45 minutes

            m_MaintenanceWorker.SafebrowsingListUpdated += OnSafebrowsingListUpdated;
        }

        #endregion

        #region IDisposable Members

        public void Dispose()
        {
            m_MaintenanceWorker.Dispose();
        }

        #endregion

        #region Events and event handlers

        /// <summary>
        /// Called when safebrowsing list has been updated.
        /// </summary>
        public event EventHandler<SafebrowsingListUpdatedEventArgs> SafebrowsingListUpdated;

        /// <summary>
        /// Called when safebrowsing list has been updated by the maintenance worker
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnSafebrowsingListUpdated(object sender, SafebrowsingListUpdatedEventArgs e)
        {
            EventHandler<SafebrowsingListUpdatedEventArgs> handler = SafebrowsingListUpdated;
            if (handler == null) return;
            handler(this, new SafebrowsingListUpdatedEventArgs(e.List));
        }

        #endregion
    }
}